Scala に見るエラーハンドリング
Motivation
Java を書いていた時は、既存コードに合わせてとりあえず検査例外を投げていたが、一から自分で設計するとなった時に (それも、検査例外のない言語で開発しているときに) どのようにエラー処理を実装すべきか、自分の中で考えがまとまっていなかったのでまとめておく。
ドの研修で利用されている Scala のテキストは非常によくできていると思う。Scala でのエラー処理を参考までに再度読み直してみる。
エラーとは何か
ユーザからの入力が悪い
文字列が長すぎる, 不正なアクセスがある, DDoS 攻撃, ...
外部サービスが悪い
サービスにつながらない, メールの送信に失敗する, ...
システム内部が悪い
ライブラリのバグ, メモリ/容量が足りない, ...
エラー処理において何を実現すべきか
例外安全性
例外が発生しても、システムがダウンしたりデータの不整合などがおきない
強い例外安全性
例外が発生したら、例外発生前の状態にロールバックする
Java のエラー処理とその注意点
null
null チェックを忘れる問題 (コンパイルは null チェック忘れを判断できない)
参照型が全て null になりうるので、null が返されるか型からはわからない (ドキュメントで表現すべき)
例外/検査例外
乱用すると、処理の流れがわかりづらくなる
検査例外は、利用側は catch したときに適切にエラー状態から回復できる場合にのみ利用すべき
使う側が適切に処理できない場合、投げられても適当な処理しかかけない
例外翻訳
受け取った例外をそのまま外に投げずに、別の例外でくるんで投げ直す手法
便利だが、乱用すると例外処理が煩雑になる
例外の問題点
非同期プログラミングとの相性が悪い
例外は、送出されたら catch されるまでコールスタックを遡る
別のイベントループや別スレッドで発生すると、catch できない
コントロールフローがわかりにくい
catch 漏れが起きたり
どこで catch しているかわからなくなったり
型チェックができない
検査例外がないと、どんな例外かどうかが型として表現されない
頼りすぎると、静的型付け言語の利点が損なわれる
Scala のエラー処理
以下の二種類がある
例外を使う方法
データ型 (Option, Either, Try 等) を使う方法
Option 型
Option 型は、簡単に言うと、値を 1 個だけいれることのできるコンテナであり、値があるか?ないか?わからない状態を表す。
Option の値
Some: 何かしらの値が格納されている
None: 何も格納されていない
基本的な使い方は以下のようになる。
code:scala
// 基本的には、get ではなくパターンマッチを使用する
// - Option 型は sealed 指定されている
// - SomeA はケースクラス、None はケースオブジェクト // - 値がなかった場合の処理をもらすこともない
val s: OptionString = Some("hoge") valしょり result = s match {
// 値の束縛ができるのも便利!!
case Some(str) => str
case None => "not matched"
}
// fold: None の場合に実行する関数を定義する
// None のとき
scala> n.fold(throw new RuntimeException)(_ * 3)
java.lang.RuntimeException
at .$anonfun$res11$1(<console>:14)
at scala.Option.fold(Option.scala:158)
... 673 elided
// None でないとき
scala> Some(3).fold(throw new RuntimeException)(_ * 3)
res12: Int = 9
// Some 同士の掛け算をしたりすると、Option の入れ子型ができてしまう
// flatten を使うことで解消できる。
// 1. 入れ子になってしまうパターン
scala> val v1: OptionInt = Some(3) scala> val v2: OptionInt = Some(5) scala> v1.map(i1 => v2.map(i2 => i1 * i2))
res13: Option[OptionInt] = Some(Some(15)) // 2. 入れ子にしないパターン
scala> v1.map(i1 => v2.map(i2 => i1 * i2)).flatten
res14: OptionInt = Some(15) // flatMap: Option に map をかけて flatten を適用してくれる
// 実装的には、単に Some でつつんでいないだけ
// これが
v1.map(i1 => v2.map(i2 => v3.map(i3 => i1 * i2 * i3)).flatten).flatten
// こうなる
v1.flatMap(i1 => v2.flatMap(i2 => v3.map(i3 => i1 * i2 * i3)))
// for を Option に使用できる
val v1: OptionInt = Some(3) val v2: OptionInt = Some(5) val v3: OptionInt = Some(7) for { i1 <- v1
i2 <- v2
i3 <- v3 } yield i1 * i2 * i3 //< Some(105)
以下のような定義になっている。
code:scala
// Optionの抽象クラス。持つかもしれない値の型をパラメータでもつ
sealed abstract class Option+A extends Product // Someは、Optionを継承して値がある場合を表現する。
// コンストラクタに実際の値をとる
final case class Some+A(x: A) extends OptionA // Noneは、値がない場合を表す。Optionの型パラメータは共変なので
// Nothing 型は、全ての型のサブタイプにあたる型。
// なので、OptionNothingは全てのOptionA型のサブタイプになる。なのでシングルトン。 case object None extends OptionNothing Option の実装をみてみる。
code:scala
// 少し実際よりも省略しているので注意
sealed abstract class Option+A extends Product with Serializable { // 値をその型の変数に変換し取り出すことができる
// しかし、未定義出会った場合には例外が発生するので、使用すべきでない
/** Returns the option's value.
* @note The option must be nonempty.
* @throws java.util.NoSuchElementException if the option is empty.
*/
def get: A
// 値が NoneValue であった場合、false
/** Returns true if the option is $none, false otherwise.
*/
def isEmpty: Boolean
// 値が NoneValue の場合 = 空の場合 = 未定義 という定義をしている
/** Returns true if the option is an instance of $some, false otherwise.
*/
def isDefined: Boolean = !isEmpty
// 引数を与えると、値が空ならば引数自身、そうでなければ値の取得を行う
// o.getOrElese("") == "" みたいな判定を行ったりする
@inline final def getOrElseB >: A(default: => B): B = if (isEmpty) default else this.get
// 値が空かどうかに応じて関数を実行できる
@inline final def foldB(ifEmpty: => B)(f: A => B): B = if (isEmpty) ifEmpty else f(this.get)
// Option が入れ子になる問題を解決する(後述)
def flattenB(implicit ev: A <:< OptionB): OptionB = if (isEmpty) None else ev(this.get)
// Option はコレクションの性質を持つ
// というのも、コレクションに利用できる様々なメソッドが定義されている
// isEmpty による判定が入っている = None の時には動作しない
// 特に map はよく使われる。結果を Some に包んで返す
@inline final def mapB(f: A => B): OptionB = if (isEmpty) None else Some(f(this.get))
@inline final def flatMapB(f: A => OptionB): OptionB = if (isEmpty) None else f(this.get)
}
Either
Option の問題点
処理が成功したかどうか?しかわからない
None の場合、値の取得に失敗したことはわかるが、エラーの状態 が取得できない
エラーの種類が問題にならない場合にのみ利用できる
Either
エラー時にエラーの種類まで取得できる
Right と Left の2つの値をもつ
code:scala
// 値の格納方法
// パターンマッチもできる
v1 match {
case Right(i) => println(i)
case Left(s) => println(s)
}
一般に、Left にエラー値、Right に正常な値をいれる。Left は代数的データ型で定義し、パターンマッチが可能なようにしておいた方が良い。例えば、以下のようにエラーを定義できる。
code:scala
sealed trait LoginError
// パスワードが間違っている場合のエラー
case object InvalidPassword extends LoginError
// nameで指定されたユーザーが見つからない場合のエラー
case object UserNotFound extends LoginError
// パスワードがロックされている場合のエラー
case object PasswordLocked extends LoginError
ログイン API 用の型は、以下のように定義できる。
code:scala
case class User(id: Long, name: String, password: String)
object LoginService {
// ユーザ名とパスワードの組が正しい場合は Right の値で返す
// 間違っていた場合は LoginError を Either の Left で返す
}
// パターンマッチで判定する
LoginService.login(name = "dwango", password = "password") match {
case Right(user) => println(s"id: ${user.id}")
// Invalid Password の処理しか行なっていない
case Left(InvalidPassword) => println(s"Invalid Password!")
// 以下に、UserNotFound, PasswordLocked 処理が続くべき。これはコンパイルエラーで教えてくれる
}
Option と同様に、for 式を使ったり map を使ったりできる。定義を少し覗いてみる。
code:scala
// 説明のためにだいぶ省略しているので注意
sealed abstract class Either+A, +B extends Product with Serializable { // パターンマッチングにより、Right の場合のみ実行される
// Left の場合、自身(Left) を返す
def mapY(f: B => Y): EitherA, Y = this match { case Right(b) => Right(f(b))
case Left(a) => this.asInstanceOf[EitherA, Y] }
case Right(b) => f(b)
case Left(a) => this.asInstanceOf[EitherAA, Y] }
}